iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

前言

第二十天終於到了,這同時也表示旅程也將到終點了,照我的規劃,剩下的天數應該完全足夠把整個專案完成,並在最後一天做個總回顧跟一些可能的發展方向,理論上應該能提出一個完整的架構讓任何人都能打造自己的 AI 專案!聽起來是挺美好的,但我們還是先把目光放在眼前的課題吧!
我們昨天處理掉幾個讓我從第二週開始就有點煩心的問題,在基本的使用體驗上應該比之前好一些了,雖然我還有觀察到幾個問題,但這部分我們可以留到最後一週再處理即可,我們今天要做的事情相對更單純一些,我們要對一個後端 API 做重構,也就是我們目前最核心的功能所在
/api/interview/evaluate/route.ts,這個 API確實功能齊全,但同時它也已經變得有些臃腫。它就像一個過於熱心的專案經理,一個人包辦了所有事情:呼叫 RAG、執行程式碼、建立 Prompt、跟 AI 溝通... 所有的邏輯都擠在同一個檔案裡。雖然現在它還能運作,但我們可以預見,當下週要加入「使用者驗證」和「儲存練習紀錄」等新功能時,這個檔案將會變成一頭難以維護的巨獸。俗話說得好:「先整理房間,再買新傢俱。」今天,我們就要來當一次整理師,在加入新功能之前,對這個核心 API 進行一次徹底的「模組化重構」。

今日目標

今天不加新功能,我們的目標很單純:將 /api/interview/evaluate/route.ts 拆分成多個職責單一、易於管理的小模組,同時也將其他散落的邏輯整理起來。

  • 擴充 lib 資料夾:讓這個工具箱有更多工具可用。
  • 拆分服務邏輯:將與 Supabase、Judge0、Gemini 相關的邏輯各自獨立成單獨的檔案。
  • 重構 route.ts:讓它回歸單純的「交通指揮」角色,只負責協調不同模組,而不是親自下場做所有事。

Step 1: 手術藍圖 - 重新規劃我們的 lib 資料夾

我們在之前的設計中就有建立lib 資料夾,當前的檔案應該只有mockData.ts,但之後它將是我們放置其他可重用、跨功能程式碼的地方。
我們的計畫是將 evaluate/route.ts 的功能拆分到以下幾個新檔案中:

  • app/lib/utils.ts:放置通用的輔助函式,例如 retryAsyncFunction
  • app/lib/supabase.ts:專門處理與 Supabase 相關的一切,包括 RAG 搜尋。
  • app/lib/judge0.ts:負責呼叫 Judge0 代理並取得程式碼執行結果,其中也會包含我們 judge0/execute/route.ts的邏輯整合。
  • app/lib/prompt.ts:集中管理我們的 Prompt 模板和建構邏輯。
  • app/lib/gemini.ts:封裝與 Google Gemini API 的所有互動。

這樣的結構能讓我們的專案一目了然,當未來需要修改 RAG 邏輯時,我們就知道要去 supabase.ts 找,而不用在巨大的 route.ts 裡大海撈針,也許你會覺得其中一些檔案不是這麼的必要,例如judge0.ts & gemini.ts這種已經有一個專用的 route.ts 的路由的情境,但實際上服務(service)本身與本來就應該與路由的邏輯分開,在路由內去呼叫服務而不是將所有邏輯都塞在路由中,即便在 POC 的階段看起來沒有這麼必要,但對於一個日漸膨脹的專案來說,職責的劃分會讓一切都更輕鬆點。

Step 2: 建立通用工具箱 utils.ts

這個檔案目前的內容會比較單純,我們現階段其實沒有用到太多的通用函數,因此你只要將原本在evalute/route.ts的兩個功能函數拿出來寫到該檔案即可,完整的檔案內容如以下:

import { ChatMessage } from '@/app/types/interview';

/**
 * 通用的非同步函式重試機制,具備指數退避策略。
 * @param asyncFn 要執行的非同步函式
 * @param retries 重試次數,預設 3 次
 * @param delay 初始延遲時間 (ms),預設 1000ms
 * @param onRetry 每次重試時呼叫的回呼函式
 * @returns 非同步函式的執行結果
 */
export async function retryAsyncFunction<T>(
  asyncFn: () => Promise<T>,
  retries = 3,
  delay = 1000,
  onRetry?: (error: Error, attempt: number) => void
): Promise<T> {
  for (let i = 0; i < retries; i++) {
    try {
      return await asyncFn();
    } catch (error) {
      if (onRetry) {
        onRetry(error as Error, i + 1);
      }
      if (i === retries - 1) throw error;
      await new Promise((res) => setTimeout(res, delay * Math.pow(2, i)));
    }
  }
  // 迴圈結束後還是失敗,拋出錯誤 (理論上不會執行到這裡,但為求型別安全)
  throw new Error('Retry failed after multiple attempts.');
}

/**
 * 將對話歷史格式化為純文字,並只取最近的訊息。
 * @param history 聊天訊息陣列
 * @returns 格式化後的純文字歷史紀錄
 */
export function formatChatHistory(history: ChatMessage[]): string {
  if (!history || history.length === 0) {
    return '無歷史對話紀錄。';
  }
  // 只取最近的 4 則訊息 (約 2 輪對話),避免 Prompt 過長
  const recentHistory = history.slice(-4);
  return recentHistory
    .map((msg) => {
      const prefix = msg.role === 'user' ? 'User' : 'AI';
      // 我們只關心對話內容,忽略 evaluation 物件
      return `${prefix}: ${msg.content}`;
    })
    .join('\n');
}

/**
 * 等待指定的毫秒數。
 * @param ms 等待的毫秒數
 * @returns 一個 Promise,在指定的時間後會被解決
 */

export const sleep = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));


之後若是有什麼通用函數,例如一些陣列處理、分組或是深拷貝之類的都可以往這丟。

Step 3: 拆分各個服務模組

下一步我們也是按照類似的邏輯,在原本在evaluate.ts檔案中的各種服務拆分出來,其實就是複製貼上後再加點註解而已,簡單!

supabase.ts

將原本相關的邏輯抽出後,用一個performRagSearch函數輸出這串邏輯,方便之餘也增了加了一點可讀性。

import { createClient } from '@supabase/supabase-js';

// 初始化 Supabase client,確保只在伺服器端使用 service key
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
);

/**
 * 執行 RAG 語意搜尋。
 * @param embedding 使用者回答的向量
 * @param questionId 目標問題 ID
 * @returns 相關的知識點內容
 */
export async function performRagSearch(
  embedding: number[],
  questionId: string
): Promise<string> {
  const { data, error } = await supabase.rpc('match_documents', {
    query_embedding: embedding,
    match_threshold: 0.7,
    match_count: 5,
    p_question_id: questionId,
  });

  if (error) {
    console.error('Supabase RAG search error:', error);
    // 在發生錯誤時回傳一個友善的訊息,而不是讓整個請求失敗
    return '知識庫查詢失敗,請稍後再試。';
  }

  return data?.length > 0
    ? data.map((d: { content: string }) => `- ${d.content}`).join('\n')
    : '在知識庫中找不到相關的參考資料。';
}

judge0.ts

同樣也是將原本相關的邏輯抽出後,用一個executeCode函數輸出這串邏輯,另一個額外的步驟則是我們也要到app/api/judge0/execute/route.ts中把相關的邏輯也一並抽出後整理到這個檔案中,完整的檔案內容如下:

// app/lib/judge0.ts
import { retryAsyncFunction, sleep } from './utils'; // 從 utils 引入需要的函數

// 為了更清晰的型別定義,我們可以為 Judge0 的回傳結果建立一個 interface
export interface Judge0ExecutionResult {
  stdout: string | null;
  stderr: string | null;
  compile_output: string | null;
  status: {
    id: number;
    description: string;
  };
}

const JUDGE0_API_HOST = process.env.JUDGE0_API_HOST;
const JUDGE0_API_KEY = process.env.JUDGE0_API_KEY;
const JAVASCRIPT_LANGUAGE_ID = 93;

/**
 * 【核心引擎】執行程式碼並取得結構化的 JSON 結果。
 * 這個函式處理與 Judge0 API 的直接通訊、輪詢和解碼。
 * @param source_code 要執行的原始碼
 * @returns 包含執行結果的物件
 * @throws 如果環境變數未設定、API 呼叫失敗或超時,則拋出錯誤
 */
export async function executeCode(
  source_code: string
): Promise<Judge0ExecutionResult> {
  if (!JUDGE0_API_HOST || !JUDGE0_API_KEY) {
    console.error('Judge0 API environment variables are not set.');
    throw new Error('Judge0 服務設定不完整。');
  }

  // Step 1: 提交程式碼
  const encodedSourceCode = Buffer.from(source_code).toString('base64');
  const submissionResponse = await fetch(
    `https://${JUDGE0_API_HOST}/submissions?base64_encoded=true&wait=false`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-RapidAPI-Key': JUDGE0_API_KEY,
        'X-RapidAPI-Host': JUDGE0_API_HOST,
      },
      body: JSON.stringify({
        source_code: encodedSourceCode,
        language_id: JAVASCRIPT_LANGUAGE_ID,
      }),
    }
  );

  if (!submissionResponse.ok) {
    const errorText = await submissionResponse.text();
    throw new Error(`提交至 Judge0 失敗: ${errorText}`);
  }

  const { token } = await submissionResponse.json();
  if (!token) throw new Error('無法從 Judge0 取得 Token');

  // Step 2: 輪詢結果
  let resultData;
  for (let i = 0; i < 10; i++) {
    await sleep(500);
    const resultResponse = await fetch(
      `https://${JUDGE0_API_HOST}/submissions/${token}?base64_encoded=true`,
      {
        method: 'GET',
        headers: {
          'X-RapidAPI-Key': JUDGE0_API_KEY,
          'X-RapidAPI-Host': JUDGE0_API_HOST,
        },
      }
    );
    if (!resultResponse.ok) continue; // 如果輪詢失敗,繼續嘗試

    resultData = await resultResponse.json();
    if (resultData.status_id > 2) break; // 執行完成
  }

  if (!resultData || resultData.status_id <= 2) {
    throw new Error('程式碼執行超時');
  }

  // Step 3: 解碼並回傳
  return {
    ...resultData,
    stdout: resultData.stdout
      ? Buffer.from(resultData.stdout, 'base64').toString('utf-8')
      : null,
    stderr: resultData.stderr
      ? Buffer.from(resultData.stderr, 'base64').toString('utf-8')
      : null,
    compile_output: resultData.compile_output
      ? Buffer.from(resultData.compile_output, 'base64').toString('utf-8')
      : null,
  };
}

/**
 * 【對外接口 for Evaluate API】執行程式碼並回傳格式化後的純文字結果。
 * 這個函式封裝了重試邏輯和結果格式化,專門給 Prompt 使用。
 * @param code 使用者提交的原始碼
 * @returns 格式化後的執行結果字串
 */
export async function getFormattedJudge0Result(code: string): Promise<string> {
  try {
    // 使用 retryAsyncFunction 來增加呼叫核心引擎的穩健性
    const result = await retryAsyncFunction(
      () => executeCode(code),
      3,
      1000,
      (error, attempt) =>
        console.warn(
          `Judge0 execution attempt ${attempt} failed: ${error.message}`
        )
    );

    // 將成功的結果格式化為 Prompt 需要的字串
    return `Status: ${result.status?.description || 'N/A'}\nStdout: ${
      result.stdout || 'N/A'
    }\nStderr: ${result.stderr || 'N/A'}`;
  } catch (error) {
    console.error('Judge0 execution failed after all retries:', error);
    // 如果最終失敗,回傳一個友善的錯誤訊息給 Prompt
    return '程式碼執行服務暫時無法連線,無法取得客觀執行結果。';
  }
}

prompt.ts

這個檔案嚴格說起來不算一個服務,你可以自己選擇要不要抽出這層邏輯,我只是單純想整理乾淨一些,也許過幾天我就打算把這個部分搬到其他地方了。

// 保持模板的獨立性,使其易於管理和修改
export const unifiedPromptTemplate = `<role>
You are a world-class senior frontend technical interviewer providing a comprehensive evaluation.
</role>
<task>
Carefully analyze the user's answer based on the provided context. Your evaluation must be grounded in the evidence given.

- **If the question is conceptual (i.e., <judge0_result> contains 'not applicable for this question')**:
  - Base your evaluation on how well the <user_answer> aligns with the key points in <rag_context>.
  - The \`grounded_evidence\` field in your JSON response MUST be \`null\`.

- **If the question is a coding challenge (i.e., <rag_context> contains 'not applicable for this question')**:
  - Base your evaluation strictly on the objective <judge0_result> and an analysis of the <user_answer> (which is user's code).
  - The \`grounded_evidence\` field in your JSON response MUST be populated with data from the execution results.

Always refer to the <conversation_history> for dialogue context.
Your response MUST be a single, valid JSON object following the schema. Answer in Traditional Chinese.
</task>
<json_schema>
{
  "summary": "string",
  "score": "number (1-5)",
  "grounded_evidence": { "tests_passed": "number|null", "tests_failed": "number|null", "stderr_excerpt": "string|null" } | null,
  "pros": ["string"],
  "cons": ["string"],
  "next_practice": ["string"]
}
</json_schema>
<conversation_history>
\${formattedHistory}
</conversation_history>
<question>
\${question}
</question>
<rag_context>
\${ragContext}
</rag_context>
<judge0_result>
\${judge0Result}
</judge0_result>
<user_answer>
\${userAnswer}
</user_answer>`;

interface PromptContext {
  formattedHistory: string;
  question: string;
  ragContext: string;
  judge0Result: string;
  userAnswer: string;
}

/**
 * 根據上下文填充統一的 Prompt 模板。
 * @param context 包含所有需要填充的資訊的物件
 * @returns 填充完畢的最終 Prompt 字串
 */
export function buildUnifiedPrompt(context: PromptContext): string {
  return unifiedPromptTemplate
    .replace(/\${formattedHistory}/g, context.formattedHistory)
    .replace(/\${question}/g, context.question)
    .replace(/\${ragContext}/g, context.ragContext)
    .replace(/\${judge0Result}/g, context.judge0Result)
    .replace(/\${userAnswer}/g, context.userAnswer);
}

gemini.ts

最後一步則是把 Gemini 相關的函數整理到這個檔案中,包含向量與一般的模型請求,完整檔案的內容如下:

// app/lib/gemini.ts
import { GoogleGenAI, Content } from '@google/genai';

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;

if (!GEMINI_API_KEY) {
  throw new Error('GEMINI_API_KEY is not set in environment variables');
}

export const genAI = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

/**
 * 生成文字的 Embedding 向量
 * @param text 要轉換成向量的文字
 * @returns 768 維的向量陣列
 */
export async function generateEmbedding(text: string): Promise<number[]> {
  const embeddingResponse = await genAI.models.embedContent({
    model: 'gemini-embedding-001',
    contents: text,
    config: {
      outputDimensionality: 768,
    },
  });

  if (
    !embeddingResponse.embeddings ||
    embeddingResponse.embeddings.length === 0
  ) {
    throw new Error('Embedding response is empty');
  }

  return embeddingResponse.embeddings[0].values || [];
}

/**
 * 使用 Gemini 生成串流回應
 * @param prompt 完整的 prompt 文字
 * @returns ReadableStream 用於串流回應
 */
export async function generateContentStream(
  prompt: string
): Promise<ReadableStream> {
  const contents: Content[] = [{ parts: [{ text: prompt }] }];

  const result = await genAI.models.generateContentStream({
    model: 'gemini-2.5-flash',
    contents: contents,
    config: {
      responseMimeType: 'application/json',
    },
  });

  return new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of result) {
        const text = chunk.text;
        if (text) {
          controller.enqueue(encoder.encode(text));
        }
      }
      controller.close();
    },
  });
}

/**
 * 使用 Gemini 生成完整回應 (非串流)
 * @param prompt 完整的 prompt 文字
 * @returns 生成的文字內容
 */
export async function generateContent(prompt: string): Promise<string> {
  const contents: Content[] = [{ parts: [{ text: prompt }] }];

  const result = await genAI.models.generateContent({
    model: 'gemini-2.5-flash',
    contents: contents,
    config: {
      responseMimeType: 'application/json',
    },
  });

  return result.text || '';
}

Step 4: 套用整理後的服務

現在各個服務都拆得很清楚了,馬上修改evaluate/route.ts的內容吧,檔案本身要修改的地方不少,最終的檔案會變為這樣,相較於之前少了不少內容,簡潔多了:

// app/api/interview/evaluate/route.ts
import { NextResponse } from 'next/server';
import questions from '@/data/questions.json';
import { formatChatHistory } from '@/app/lib/utils';
import { buildUnifiedPrompt } from '@/app/lib/prompt';
import { performRagSearch } from '@/app/lib/supabase';
import { generateEmbedding, generateContentStream } from '@/app/lib/gemini';
import { getFormattedJudge0Result } from '@/app/lib/judge0';

export async function POST(request: Request) {
  try {
    const { questionId, answer, history } = await request.json();

    const question = questions.find((q) => q.id === questionId);
    if (!question) {
      return NextResponse.json(
        { error: 'Question not found' },
        { status: 404 }
      );
    }

    // 準備所有需要的上下文變數
    const formattedHistory = formatChatHistory(history);
    let ragContext = 'not applicable for this question';
    let judge0ResultText = 'not applicable for this question';

    if (question.type === 'concept') {
      // --- 概念題路徑 (RAG) ---
      const answerEmbedding = await generateEmbedding(answer);
      ragContext = await performRagSearch(answerEmbedding, questionId);
    } else if (question.type === 'code') {
      judge0ResultText = await getFormattedJudge0Result(answer);
    }

    // 填充統一的 Prompt 模板
    const finalPrompt = buildUnifiedPrompt({
      formattedHistory,
      question: question.question,
      ragContext,
      judge0Result: judge0ResultText,
      userAnswer: answer,
    });

    if (!finalPrompt) {
      return NextResponse.json(
        { error: 'Invalid question type' },
        { status: 400 }
      );
    }

    const stream = await generateContentStream(finalPrompt);
    return new Response(stream, {
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
    });
  } catch (error) {
    console.error('Error in evaluation API:', error);
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }
}

別忘了app/api/judge0/execute/route.ts檔案也要跟著修正。

// app/api/judge0/execute/route.ts
import { NextResponse } from 'next/server';
import { executeCode } from '@/app/lib/judge0';

export async function POST(request: Request) {
  try {
    const { source_code } = await request.json();

    if (!source_code) {
      return NextResponse.json({ error: '缺少 source_code' }, { status: 400 });
    }

    const result = await executeCode(source_code);
    return NextResponse.json(result);
  } catch (error) {
    console.error('代理 /api/judge0/execute 錯誤:', error);
    return NextResponse.json({ error: '代理伺服器內部錯誤' }, { status: 500 });
  }
}

今日回顧

今天多做了一點工,但我們總算把路由的邏輯簡化了,就像我一開始說的,路由本身就不該包含太多邏輯,呼叫服務並回傳結果就行了,現在兩個核心的 API 路由都有著相當簡潔好懂的結構,做得很好~!放假啦!

✅ 建立了職責分明的 lib 資料夾,將通用函式 (utils)、各項服務 (supabase, judge0, gemini) 與 Prompt 模板 (prompt) 各歸其位。
✅ 成功將所有外部服務的核心邏輯抽離,變成了獨立、可重用、易於測試的服務模組。
✅ 將 /api/interview/evaluate 這個最核心的路由,重構成一個乾淨、易讀的「總指揮」,只負責協調各個服務,不再處理瑣碎的實作細節。

明日預告

今天這場徹底的「程式碼大掃除」,正是為了迎接要進場的「新傢俱」——使用者系統。
我們的 AI 面試官現在很會面試,但它還不認識任何人,也記不住任何人的表現。所有的對話都是一次性的,這顯然不夠!
從明天 (Day 21) 開始,我們將進入專案的下一個重要階段:使用者系統與資料持久化。我們將會使用 Supabase Auth 來快速打造登入/註冊功能,並建立儲存使用者練習紀錄的資料表,讓每一次的面試成果都能被永久保存下來。

我們明天見!

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-20


上一篇
小小技術債處理:別讓以後的自己痛苦 Part 1
下一篇
再訪 Supabase:建立使用者系統與資料庫基石
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言